到昨天為止,每請 AI 做完一個功能,它有時會自己跑一次 lint、build,甚至打開幾個頁面點點看。
問題是,這些檢查通常只針對它剛改的地方,不一定會把整個網站的重要流程都重新走一遍,也不一定抓得到藏在其他地方的 bug。而且 AI 這次有檢查,下次不一定記得。也有人會選擇每改一次,就自己手動測一輪。但功能一多,要測的項目越來越多,越測越累,藏在角落的問題還是可能漏掉。
不管是 AI 檢查,還是自己手動測,都有一個共同的問題:要有人記得做,而且每次做的不一定一樣。
今天要把這些檢查,變成每次修改都會自動發生、而且每次都一樣的作息。
CI/CD 是兩個縮寫,講的是程式碼從「寫好」到「上線」中間的兩段路:
| 全名 | 意思 | |
|---|---|---|
| CI | Continuous Integration,持續整合 | 每次要把修改放進主線之前,先自動檢查、測試,確認沒有壞掉 |
| CD | Continuous Deployment,持續部署 | 通過檢查之後,自動送上線 |
「整合」指的是把修改放回主線(main)。不管是多人協作,還是我和 AI 一天改好幾次,每次放回去之前都先檢查一遍,問題就會在剛發生的時候被抓到,而不是累積到上線才爆開。
如果沒有 CI/CD,流程大概是這樣:
寫完 → 自己想起來才測 → 手動上傳到主機 → 上線後才發現壞了
有了 CI/CD:
寫完 → 推上去 → 自動檢查、測試 → 通過才自動上線
差別不只是省事,而是每一次都會經過同樣的關卡,不管那天記不記得、忙不忙。
CD 有時也指 Continuous Delivery(持續交付):程式隨時保持可以上線的狀態,但最後一步由人按下去。這篇講的是自動部署的那一種。
現在的 AI 很會檢查:跑 lint、跑 build,甚至自己開瀏覽器點一輪。那還需要什麼?
| AI 幫我檢查 | CI(持續整合) | |
|---|---|---|
| 什麼時候跑 | 我或 AI 想到的時候 | 開 PR、更新 PR、合併到 main 時,自動跑 |
| 在哪裡跑 | 我的電腦,有快取、有 .env |
GitHub 提供的虛擬機,每次都從新的環境開始 |
| 每次檢查什麼 | 當下決定,每次可能不一樣 | 寫好的步驟,每次都一樣 |
| 怎麼判斷對錯 | AI 看了覺得沒問題 | 寫好的條件,不符合就失敗 |
| 失敗會怎樣 | AI 告訴我 | 留下明確的失敗紀錄;搭配門禁,才能擋住上線(5.5) |
AI 可以幫我做檢查;CI 是確保這些檢查一定會發生。
Vibe Coding 還有兩個特別要小心的地方:AI 常常只檢查它改的那一塊;測試失敗時,AI 也可能改的是測試,而不是程式。反過來也成立:我實際操作時,E2E 第一次就失敗了兩次,但網站其實沒壞,是測試寫錯了畫面元素的名稱。所以測試失敗時,第一件事是判斷:錯的是程式,還是測試?測試本身,也要自己看過。
兩者其實可以接起來:讓 AI 開瀏覽器走一遍、找出問題;修好之後,請它把這個流程寫成測試,放進 CI。AI 的探索只有一次,寫成測試之後,就變成每天的作息。

【圖 1|從開分支到上線】

【圖 2|Vercel 部署列表:分支是 Preview,main 是 Production】
GitHub Actions 不是在我的電腦上跑。GitHub 有提供虛擬機:每次推程式碼,GitHub 都會臨時開一台新的,下載程式碼、安裝套件、跑完所有檢查,然後整台刪掉。我的電腦關著也沒關係;而且因為每次都從零開始,「在我電腦上可以跑,但其他電腦會出問題」這種問題,在這裡就很容易現形。
測試可以照「一次測多大的範圍」來分。用這個系列一直在用的餐廳來比喻:
| 種類 | 測什麼 | 餐廳的比喻 |
|---|---|---|
| 單元測試 | 一小段邏輯的輸入輸出,前端、後端都可以有 | 在廚房單獨試一口醬汁 |
| API、資料庫測試 | 後端的一個入口,或一條權限規則 | 檢查點餐單送進廚房有沒有照規定處理;沒權限的人進不進得了倉庫 |
| E2E(End-to-End,端到端)測試 | 從瀏覽器出發,經過前端、伺服器、資料庫,再回到畫面,整條路走一遍 | 假扮客人,從進門、點餐、上菜到結帳,整個走一遍 |
越往下,越接近真實狀況,也抓得到「每一段單獨看都沒問題,接起來卻壞了」的問題;但也越慢,中間任何一段不穩,測試就可能失敗。所以單元測試可以多寫,E2E 只挑最重要的幾條流程。
另外,程式碼檢查、型別檢查、打包,嚴格來說不算「測試」,但它們最便宜、最快,一樣放進每次的檢查裡。
| 檢查 | 抓什麼 | Biashelf 的例子 |
|---|---|---|
| 程式碼檢查 | 寫法不規範、明顯的錯誤 | 用了沒宣告的變數 |
| 型別檢查 | 資料的形狀對不上 | 把字串傳給要數字的地方 |
| 打包 | 能不能成功建置 | 引入了不存在的檔案 |
| 單元測試 | 一小段邏輯 | 最近瀏覽:去除重複、最新的在前、最多 10 筆 |
| 權限測試 | 該擋的有沒有擋住 | 沒登入的人,讀不到任何錯誤回報(Day 19、21 的 RLS) |
| E2E 測試 | 使用者的完整操作 | 從首頁進到搜尋 → 搜尋 → 打開專輯;直接打開專輯網址 |
權限測試特別值得放進來:它守的是 Day 19、21 花最多力氣做的東西。一個 migration 改錯,就可能讓別人看到不該看的資料,而畫面上完全看不出來。這次只做「讀」的檢查,因為只讀不會改到正式資料。
權限測試不是另一種測試,而是測試的目的;這裡用的是 API 測試的方式,直接用不同身分去存取資料。
| 類型 | 常見工具 | 這次選 | 理由 |
|---|---|---|---|
| 程式碼檢查 | ESLint、Biome、Oxlint | ESLint | Next.js 官方支援;next lint 在 Next.js 15 被標示淘汰、16 已移除,改成直接執行 ESLint。這個專案原本沒有完整的設定,所以這次補上 |
| 型別檢查 | TypeScript | TypeScript | 專案本來就用 |
| 單元測試 | Vitest、Jest、Node 內建的測試工具 | Vitest | 速度快、設定少,寫法跟 Jest 幾乎一樣 |
| API 測試 | Vitest+fetch、Supertest、Postman/Newman | Vitest | 權限測試直接用 Vitest 寫,不用另外加工具 |
| 資料庫測試 | pgTAP(Supabase 內建支援) | 這次不做 | 需要測試資料庫 |
| E2E 測試 | Playwright、Cypress、Selenium | Playwright | 能開多種瀏覽器、會自動等畫面載入、官方有提供 GitHub Actions 的使用方式和範例 |
| 跑版比對 | Playwright 截圖、Chromatic、Percy、Applitools | 這次不做 | 基準圖要維護 |
| CI 平台 | GitHub Actions、GitLab CI、CircleCI、Bitbucket Pipelines | GitHub Actions | 程式碼本來就在 GitHub,私人 repo 也有免費額度 |
| 想測的東西 | 可以怎麼做 | 這次為什麼不做 |
|---|---|---|
| 登入後的功能(網站有帳號密碼登入) | 開一個權限最小的測試帳號,帳密放在 GitHub Secrets;測試前登入一次,把登入狀態存起來給每個測試用(這個檔案不能 commit) | Biashelf 沒有密碼登入 |
| 登入後的功能(只有驗證碼、Google 登入) | 在測試資料庫裡建立測試帳號和登入狀態 | 需要測試資料庫;不要為了測試多開密碼登入,或做「輸入 999999 就能登入」這種後門 |
| 會寫入資料的流程 | 在測完就丟的測試資料庫裡寫,例如在 CI 裡跑本機版 Supabase | 目前開發和正式共用同一個資料庫(Day 23) |
| 畫面跑版 | Playwright 截圖,跟「基準圖」逐像素比對,失敗時產生用紅色標出差異的比較圖;可以設定容許的差異比例,把時間這類會變動的區塊遮掉 | 基準圖要維護;Mac 和 CI 的 Linux 字型渲染不同,基準圖要在 CI 裡產生 |
| 手機版 | 同一條 E2E,換成手機尺寸再跑一次 | 可以很快加上,這次先控制篇幅 |
| 第三方服務 | Google 登入、寄信,用手動驗收確認串接(Day 18、24 都做過) | 對方的服務穩不穩,不該決定我的 CI 會不會過 |
不管哪一種,能登入之後,會寫入資料的測試,建議還是需要一個獨立的測試資料庫。這也是 Day 23 說「先知道風險」的那件事,今天正式碰到了。
測試的種類還很多:資安掃描、上線後的巡檢、效能檢查,有機會會在後續的文章出現;無障礙、跨瀏覽器、壓力測試,則等網站規模更大再說。重點不是一次做齊,而是知道有哪些門,再依照風險決定先守哪一扇。
大部分只要設定一次,之後推程式碼,CI 就會自動跑:
| 在哪裡 | 設定什麼 | 誰來做 |
|---|---|---|
| 專案 | 固定 Node.js 版本(.nvmrc、package.json 的 engines),跟 Vercel 一致 |
交給 AI |
| 專案 | 補上 ESLint 設定;安裝 Vitest、Playwright;加上 typecheck、test、test:permissions、test:e2e 指令;把測試報告資料夾加進 .gitignore |
交給 AI |
| 專案 | 新增 .github/workflows/ci.yml |
交給 AI |
| GitHub 後台 | Settings → Secrets and variables → Actions:NEXT_PUBLIC_SUPABASE_URL 放 Variables,NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY 放 Secrets |
自己 |
| Vercel 後台 | 設定 Deployment Checks(5.5) | 自己 |
| 工作方式 | 改成開分支 → 推上去 → 開 PR → 合併到 main | 自己 |
先將測試過程中可能會用到的變數加入 Github Actions 中:
【圖 3|GitHub 的 Settings → Secrets and variables → Actions】
(因為我有測資料庫相關的,所以添加資料庫 URL 跟 publishable key)
最適合當第一個測試的,是 Day 22 的最近瀏覽紀錄:不需要資料庫,做了什麼、應該得到什麼,都很明確,例如:
// tests/unit/local-history.test.ts
// @vitest-environment jsdom
test("重複瀏覽會移到最前面,而且不會重複", () => {
recordRecentlyViewed("album-a");
recordRecentlyViewed("album-b");
recordRecentlyViewed("album-c");
recordRecentlyViewed("album-b");
expect(readRecentlyViewed()).toEqual(["album-b", "album-c", "album-a"]);
});
最近瀏覽是存在瀏覽器的 localStorage 裡,所以第一行的 jsdom 是請 Vitest 模擬一個瀏覽器環境。實際一共寫了 6 個案例:新的在最前面、重複的移到最前面、不會重複、最多 10 筆、不合法的 ID 不寫入、localStorage 被塞了壞掉的資料也不會出錯。
用 Vitest 加上 Supabase 的 publishable key,模擬一個「沒登入的訪客」,直接去讀錯誤回報的資料表:
// tests/integration/data-reports-permissions.test.ts
test("沒登入的人,讀不到任何錯誤回報", async () => {
const { error } = await anon.from("data_reports").select("id").limit(1);
if (!error) throw new Error("Anonymous read unexpectedly succeeded");
expect(error.code).toBe("42501"); // 沒有權限
});
「讀不到」有兩種:RLS 把資料藏起來(查詢成功但 0 筆),或根本沒有讀取權限(查詢直接失敗)。Biashelf 是後者,Day 21 沒有開放訪客讀這張表,所以預期拿到權限不足的錯誤碼 42501;寫死其中一種,權限被改動時才分得出來。
測試也刻意不印出查到的資料,萬一權限真的被打開,回報內容也不會印進 CI 紀錄。它只讀不寫,所以目前先在正式資料庫上跑,代價是正式的 Supabase 暫時連不上時,CI 也會變紅;長期還是應該有獨立的測試環境。
Playwright 會啟動真正的瀏覽器,預設在背景、沒有畫面的模式(headless)執行,照著腳本點擊、輸入,再檢查畫面上該出現的東西有沒有出現。這篇先挑兩條不需要登入、也不會寫入資料的流程為例:
aespa 按 Enter → 網址變成 ?q=aespa → 點開《Armageddon》→ 專輯詳情的抽屜打開/albums/aespa-armageddon → 正常顯示專輯名稱和版本列表// tests/e2e/catalog.spec.ts
test("searches from the home page and opens the album drawer", async ({ page }) => {
await page.goto("/");
await page.getByRole("link", { name: "搜尋專輯配置" }).click();
await page.getByRole("textbox", { name: "搜尋專輯" }).fill("aespa");
await page.getByRole("textbox", { name: "搜尋專輯" }).press("Enter");
await expect(page).toHaveURL(/\/albums\?q=aespa$/);
await page.getByRole("link", { name: /查看 .*《Armageddon》完整專輯配置/ }).click();
const drawer = page.getByRole("dialog", { name: "專輯配置詳情" });
await expect(drawer.getByRole("heading", { name: "Armageddon", level: 1 })).toBeVisible();
});
找元素時用的是「角色加名稱」,例如「名字叫搜尋專輯的輸入框」,跟使用者看畫面的方式比較接近,改樣式也不容易壞。網站不用自己先開:Playwright 的設定檔寫好啟動指令,它會自己把網站開起來、測完再關掉;在 CI 裡就啟動前一步 build 好的版本,跑在虛擬機自己的 localhost 上。想看它實際怎麼點,可以在本機用 npx playwright test --ui 一步步看畫面。
在 .github/workflows/ 放一份設定檔,告訴 GitHub:開 PR 和合併到 main 時,就依序做這些事。
# .github/workflows/ci.yml(節錄)
name: CI
on:
pull_request: # 開 PR、更新 PR 時
push:
branches: [main] # 合併到 main 時
permissions:
contents: read # 讀取程式碼
actions: read # 讀取這次執行的結果(回報給 Vercel 用)
statuses: write # 把結果回報成 commit 狀態(5.5)
jobs:
check:
runs-on: ubuntu-latest
timeout-minutes: 20
env:
NEXT_PUBLIC_SUPABASE_URL: ${{ vars.NEXT_PUBLIC_SUPABASE_URL }}
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
- run: npm ci
- run: npm run lint
- run: npm run typecheck
- run: npm test # 單元測試(vitest run)
- run: npm run test:auth # 既有的登入相關測試
- run: npm run test:permissions # 權限測試
- run: npm run build
- run: npx playwright install --with-deps chromium
- run: npm run test:e2e
- name: Notify Vercel # 5.5
if: always()
uses: vercel/repository-dispatch/actions/status@v1
with:
name: "Vercel - biashelf: ci"
開 PR 或更新 PR 時檢查一次;合併到 main 後再檢查一次,結果交給 Vercel(main 可能混進了別的修改,而且 Vercel 看的是 main 上那個 commit)。permissions 只開這次真正需要的三項,Node 版本則跟本機、Vercel 統一成 24。這次用不到 secret key,就不提供。
備註:AI 最開始的寫法是
push和pull_request都會觸發,同一個 commit 跑了兩次,後來才改成現在的寫法(只有 PR 才觸發)。publishable key 一開始放在 Variables,結果值會直接印在執行紀錄裡,最後改放 Secrets。能公開,不代表適合到處出現。

【圖 4|PR 頁面:同一個 commit 跑了 pull_request 和 push 兩次 CI】

【圖 5|GitHub Actions 執行列表:一筆是 PR,一筆是合併到 main】
以上 5.1 ~ 5.4 都可以透過 AI 輔助,可以詢問他建議要測什麼,或是說你自己測試過哪邊比較容易出錯,跟他說明,交給他產生相關的測試程式碼。但也要記得有時候他寫的測試流程如果測試失敗,不一定代表程式碼有錯,也有可能是測試的內容寫錯。
只有 CI 還不夠。如果 CI 紅了還能照樣合併、上線,它就只是提醒。最理想的門禁在合併之前,但我目前的 GitHub 免費方案私人 repo,沒辦法把檢查設成合併的強制門檻。所以這次退一步,把硬門檻放在 Vercel 的 Deployment Checks:合併到 main 後,Vercel 一樣會建立正式版,但要等指定的檢查通過,才換到正式網址。擋不住合併,就擋住上線。

【圖 6|Vercel 的 Add checks:選擇 Import from GitHub】
這次設定時,Vercel 沒有列出我要的檢查,所以我先在 workflow 最後加上一步,主動把結果回報給 Vercel(5.4 的
Notify Vercel)。這一步一開始還因為權限不足失敗了兩次,補上權限、成功回報之後,清單裡還是沒有自動出現;最後是在 Import from GitHub 裡輸入那次 commit 的完整 SHA,才找到並加入這個檢查。回報那一步要加上
if: always(),不然測試一失敗,這一步就被跳過,Vercel 永遠收不到「失敗」。依照 Vercel 的設計,必要的檢查沒通過時,這個版本會停在 Deployment Checks,使用者看到的還是上一個版本。

【圖 7|Vercel 正式版部署:必要檢查通過後,才進到 Assigning Custom Domains】
我用的是 Vercel 的 Pro 方案;免費的 Hobby 方案能不能使用 Deployment Checks,需要以 Vercel 的官方說明為準。理想的架構不一定要現在花錢做到,但要知道門在哪裡、目前擋得住什麼。
之後改 workflow,要重新設定 Vercel 嗎?
不用。Vercel 認的是回報的名稱(
Vercel - biashelf: ci),不是 workflow 的內容;增減測試、改觸發條件、改 Node 版本都不影響。只有改了這個名稱,才要回 Vercel 新增檢查,而且要先加新的、確認可以用,再刪舊的,中間才不會沒有門禁。
一個專案也可以有好幾份 workflow,但各自要用不同的名稱回報,不然結果會互相覆蓋。
| 坑 | 怎麼解決 |
|---|---|
| E2E 失敗,但網站沒壞:測試裡寫的元素名稱跟畫面不一樣 | 看 Playwright 失敗時產生的報告,裡面列出畫面上實際的元素名稱 |
| 權限測試讀不到環境變數 | Vitest 不會自動讀 .env.local,要在設定檔裡載入 |
npm run lint 跳出互動選單,放進 CI 會卡住 |
next lint 已被淘汰,改用 ESLint 並補上設定檔 |
| 第一次加 lint,冒出 44 個問題 | 先修錯誤,警告留著之後處理。CI 一上線,過去欠的債都會被翻出來 |
| 本機、CI、Vercel 的 Node 版本不一樣 | 全部固定成同一個版本 |
| Vercel 找不到 GitHub Actions 的檢查 | 在 workflow 主動回報,並加上 if: always() |
回報給 Vercel 時出現 Resource not accessible by integration |
權限不夠,依錯誤訊息補上 actions: read、statuses: write |
| publishable key 出現在執行紀錄和截圖裡 | 改放 Secrets;舊紀錄要自己處理 |
| 同一個 commit 跑了兩次 CI | push 和 pull_request 同時觸發;改成 PR 時跑,push 只限 main |
還有一個插曲:build 跑到一半時,Codex 一度判斷「Node 24 跟 Next.js 不相容,要把 Vercel 降回 22」。持續追下去才發現,只是工具先回傳了結果,build 其實還在背景跑,最後是成功的。AI 說它測過了要看證據,AI 下的結論也一樣。
| 測試 | 預期結果 |
|---|---|
| 開 PR,看 GitHub Actions | lint、型別、單元測試、權限測試、build、E2E 全部通過 |
| 權限測試 | 訪客讀取錯誤回報,拿到 42501 權限不足(不在正式資料庫故意打開權限來測試) |
| 合併到 main,看 Vercel 正式版 | Vercel - biashelf: ci 通過之後,才換到正式網址 |
| 在分支上故意拿掉最近瀏覽的「去除重複」 | 單元測試失敗,CI 變紅 |
| 在分支上故意改錯 E2E 要找的元素名稱 | E2E 失敗,CI 變紅 |
| 產品 | 最常用 E2E 守住的流程 |
|---|---|
| 電商 | 加入購物車 → 結帳 |
| SaaS | 註冊 → 付款 → 開通功能 |
| 線上課程 | 報名 → 進入課程頁 |
每個產品都有幾條「一斷就直接損失」的路,E2E 就是用來守住它們的。
今天的新觀念:
| 觀念 | 一句話 |
|---|---|
| CI | 每次要放進主線前,自動確認沒有壞掉 |
| CD | 通過之後,自動送上線 |
| 單元測試 | 測一小段邏輯的輸入輸出 |
| E2E 測試 | 用真的瀏覽器,走一遍使用者的操作 |
| 門禁 | 沒通過就不准合併,或不准上線 |
今天沒有新增任何功能,而是把前面做過的功能(Day 10 的專輯網址、Day 11 的搜尋、Day 19 和 21 的權限、Day 22 的最近瀏覽),變成「不能偷偷壞掉」的規則。 AI 讓寫程式變快了,但「改了這裡、壞了那裡」的問題仍然存在。CI 不是不相信 AI,而是不靠任何人記得:每次都檢查,每次都一樣。
不過,測試全綠,只代表功能照預期在跑,不代表網站是安全的,許多攻擊者就像鯊魚一樣。而海裡的鯊魚,不會因為你的船漂亮就游過來,也不會因為你的網站很小就放過你。牠們只是一直在附近繞,等著聞到血。網路上的攻擊也一樣,大多不是衝著誰來,而是用程式不停地掃,哪裡有已知的漏洞、外洩的金鑰、沒擋好的輸入,就往哪裡咬。
明天,我們來看看網站上有哪些地方,最容易滲出血腥味。
我們明天見。